//
// Copyright © 2025 Stream.io Inc. All rights reserved.
//

import CoreData

/// This enum describes the changes of the given collections of items.
public enum ListChange<Item> {
    /// A new item was inserted on the given index path.
    case insert(_ item: Item, index: IndexPath)

    /// An item was moved from `fromIndex` to `toIndex`. Moving an item also automatically mean you should reload its UI.
    case move(_ item: Item, fromIndex: IndexPath, toIndex: IndexPath)

    /// An item was updated at the given `index`. An `update` change is also automatically generated by moving an item.
    case update(_ item: Item, index: IndexPath)

    /// An item was removed from the given `index`.
    case remove(_ item: Item, index: IndexPath)
}

extension ListChange: CustomStringConvertible {
    /// Returns pretty `ListChange` type description.
    public var description: String {
        let indexPathDescription: (IndexPath) -> String = { indexPath in
            "(\(indexPath.item), \(indexPath.section))"
        }
        switch self {
        case let .insert(_, indexPath):
            return "Insert \(indexPathDescription(indexPath))"
        case let .move(_, from, to):
            return "Move \(indexPathDescription(from)) to \(indexPathDescription(to))"
        case let .update(_, indexPath):
            return "Update \(indexPathDescription(indexPath))"
        case let .remove(_, indexPath):
            return "Remove \(indexPathDescription(indexPath))"
        }
    }
}

extension ListChange where Item == ChatMessage {
    public var debugDescription: String {
        "\(description) - (text:\(item.text), id:\(item.id))"
    }
}

extension ListChange where Item == ChatChannel {
    public var debugDescription: String {
        "\(description) - (cid:\(item.cid))"
    }
}

extension ListChange {
    /// Returns the underlaying item that was changed.
    public var item: Item {
        switch self {
        case let .insert(item, _):
            return item
        case let .move(item, _, _):
            return item
        case let .remove(item, _):
            return item
        case let .update(item, _):
            return item
        }
    }

    /// Returns true if the change is a move.
    public var isMove: Bool {
        switch self {
        case .move:
            return true
        default:
            return false
        }
    }

    /// Returns true if the change is an insertions.
    public var isInsertion: Bool {
        switch self {
        case .insert:
            return true
        default:
            return false
        }
    }

    /// Returns true if the change is a remove.
    public var isRemove: Bool {
        switch self {
        case .remove:
            return true
        default:
            return false
        }
    }

    /// Returns true if the change is an update.
    public var isUpdate: Bool {
        switch self {
        case .update:
            return true
        default:
            return false
        }
    }

    /// The IndexPath of the change.
    public var indexPath: IndexPath {
        switch self {
        case let .insert(_, index):
            return index
        case let .move(_, _, toIndex):
            return toIndex
        case let .update(_, index):
            return index
        case let .remove(_, index):
            return index
        }
    }

    /// Returns `ListChange` of the same type but for the specific `Item` field.
    func fieldChange<Value>(_ path: KeyPath<Item, Value>) -> ListChange<Value> {
        let field = item[keyPath: path]
        switch self {
        case let .insert(_, at):
            return .insert(field, index: at)
        case let .move(_, from, to):
            return .move(field, fromIndex: from, toIndex: to)
        case let .remove(_, at):
            return .remove(field, index: at)
        case let .update(_, at):
            return .update(field, index: at)
        }
    }
}

extension ListChange: Equatable where Item: Equatable {}

/// When this object is set as `NSFetchedResultsControllerDelegate`, it aggregates the callbacks from the fetched results
/// controller and forwards them in the way of `[Change<Item>]`. You can set the `onDidChange` callback to receive these updates.
class ListChangeAggregator<DTO: NSManagedObject, Item>: NSObject, NSFetchedResultsControllerDelegate {
    /// Used for converting the `DTO`s provided by `FetchResultsController` to the resulting `Item`.
    let itemCreator: (DTO) throws -> Item

    /// Called when the aggregator is about to change the current content. It gets called when the `FetchedResultsController`
    /// calls `controllerWillChangeContent` on its delegate.
    var onWillChange: (() -> Void)?

    /// Called with the aggregated changes after `FetchResultsController` calls controllerDidChangeContent` on its delegate.
    var onDidChange: (([ListChange<Item>]) -> Void)?

    /// An array of changes in the current update.
    private var currentChanges: [ListChange<DTO>] = []

    /// Creates a new `ChangeAggregator`.
    ///
    /// - Parameter itemCreator: Used for converting the `NSManagedObject`s provided by `FetchResultsController`
    /// to the resulting `Item`.
    init(itemCreator: @escaping (DTO) throws -> Item) {
        self.itemCreator = itemCreator
    }

    // MARK: - NSFetchedResultsControllerDelegate

    // This should ideally be in the extensions but it's not possible to implement @objc methods in extensions of generic types.

    func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
        onWillChange?()
        currentChanges = []
    }

    func controller(
        _ controller: NSFetchedResultsController<NSFetchRequestResult>,
        didChange anObject: Any,
        at indexPath: IndexPath?,
        for type: NSFetchedResultsChangeType,
        newIndexPath: IndexPath?
    ) {
        // Model conversions must happen in `controllerDidChangeContent`. Otherwise, it can trigger a loop where
        // this delegate method is called again when additional fetch requests in `asModel()` are triggered.
        guard let dto = anObject as? DTO else {
            log.debug("Skipping the update from DB because the DTO has invalid type: \(anObject)")
            return
        }

        switch type {
        case .insert:
            guard let index = newIndexPath else {
                log.warning("Skipping the update from DB because `newIndexPath` is missing for `.insert` change.")
                return
            }
            currentChanges.append(.insert(dto, index: index))

        case .move:
            guard let fromIndex = indexPath, let toIndex = newIndexPath else {
                log.warning("Skipping the update from DB because `indexPath` or `newIndexPath` are missing for `.move` change.")
                return
            }
            currentChanges.append(.move(dto, fromIndex: fromIndex, toIndex: toIndex))

        case .update:
            guard let index = indexPath else {
                log.warning("Skipping the update from DB because `indexPath` is missing for `.update` change.")
                return
            }
            currentChanges.append(.update(dto, index: index))

        case .delete:
            guard let index = indexPath else {
                log.warning("Skipping the update from DB because `indexPath` is missing for `.delete` change.")
                return
            }
            currentChanges.append(.remove(dto, index: index))

        default:
            break
        }
    }

    func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
        // Model conversion is safe when all the changes have been processed (Core Data's _processRecentChanges can be called if conversion triggers additional fetch requests).
        let itemChanges = currentChanges.compactMap { dtoChange in
            do {
                switch dtoChange {
                case .update(let dto, index: let indexPath):
                    return try ListChange.update(itemCreator(dto), index: indexPath)
                case .insert(let dto, index: let indexPath):
                    return try ListChange.insert(itemCreator(dto), index: indexPath)
                case .move(let dto, fromIndex: let fromIndex, toIndex: let toIndex):
                    return try ListChange.move(itemCreator(dto), fromIndex: fromIndex, toIndex: toIndex)
                case .remove(let dto, index: let indexPath):
                    return try ListChange.remove(itemCreator(dto), index: indexPath)
                }
            } catch {
                log.debug("Skipping the update from DB because the DTO can't be converted to the model object: \(error)")
                return nil
            }
        }
        onDidChange?(itemChanges)
        currentChanges.removeAll()
    }
}
